<?php

/**
 * LDAP authentication scripts for the shoehorn project
 * 
 * @package shoehorn
 * @subpackage auth
 */

/**
 * LDAP Authentication Class
 * 
 * Used to manage user authentication on the Calvary Intranet.
 * Authenticates against Calvary's Active Directory Server.
 * 
 * @package shoehorn
 * @subpackage auth
 * @subpackage ldap
 *
 */
class Shoehorn_Auth_Ldap extends Shoehorn_Auth 
{
  
  /**
   * IP address of the remote user
   *
   * @var string
   */
  private $ip = null;
  
  /**
   * Whether or not the user has been authenticated
   *
   * @var bool
   */
  private $authenticated = false;
  
  /**
   * Groups the user is a member of
   *
   * @var array
   */
  private $groups = array();
  
  /**
   * User details, such as Name, Email, and stuff like that
   *
   * @var array
   */
  private $userDetails = array();
  
  /**
   * Error object
   *
   * @var object
   */
  protected $err;
  
  /**
   * Messages the ldapauth class generates
   *
   * @var array
   */
  public $msg = array();
  
  
  /**
   * Class construct.
   * 
   * @param object $err
   */
  function __construct(&$err) 
  {
    //start the session
    session_start();
    
    parent::__construct($err);
    
    //check to see if we are already logged in
    if(isset($_SESSION["authenticated"]) 
        && $_SESSION["authenticated"] == true 
        && isset($_SESSION["id"]) 
        && $_SESSION["id"] == md5($_SERVER['REMOTE_ADDR'])
        && isset($_SESSION["userDetails"]) 
        && is_array($_SESSION["userDetails"])
        && isset($_SESSION["groups"])
        && is_array($_SESSION["groups"])
        && count($_SESSION["groups"])>0) {
      $this->authenticated = true;
      $this->groups = $_SESSION["groups"];
      $this->userDetails = $_SESSION["userDetails"];
    }
        
  }
  
  // Returns the textual SID
	function bin_to_str_sid($binsid) {
	    $hex_sid = bin2hex($binsid);
	    $rev = hexdec(substr($hex_sid, 0, 2));
	    $subcount = hexdec(substr($hex_sid, 2, 2));
	    $auth = hexdec(substr($hex_sid, 4, 12));
	    $result    = "$rev-$auth";
	
	    for ($x=0;$x < $subcount; $x++) {
	        $subauth[$x] =
	            hexdec($this->little_endian(substr($hex_sid, 16 + ($x * 8), 8)));
	        $result .= "-" . $subauth[$x];
	    }
	
	    // Cheat by tacking on the S-
	    return 'S-' . $result;
	}
	
	// This function will convert a binary value guid into a valid string.
	function bin_to_str_guid($object_guid) {
	    $hex_guid = bin2hex($object_guid);
	    $hex_guid_to_guid_str = '';
	    for($k = 1; $k <= 4; ++$k) {
	        $hex_guid_to_guid_str .= substr($hex_guid, 8 - 2 * $k, 2);
	    }
	    $hex_guid_to_guid_str .= '-';
	    for($k = 1; $k <= 2; ++$k) {
	        $hex_guid_to_guid_str .= substr($hex_guid, 12 - 2 * $k, 2);
	    }
	    $hex_guid_to_guid_str .= '-';
	    for($k = 1; $k <= 2; ++$k) {
	        $hex_guid_to_guid_str .= substr($hex_guid, 16 - 2 * $k, 2);
	    }
	    $hex_guid_to_guid_str .= '-' . substr($hex_guid, 16, 4);
	    $hex_guid_to_guid_str .= '-' . substr($hex_guid, 20);
	
	    return strtoupper($hex_guid_to_guid_str);
	}
	
	// Converts a little-endian hex-number to one, that 'hexdec' can convert
	function little_endian($hex) {
		$result = "";
	    for ($x = strlen($hex) - 2; $x >= 0; $x = $x - 2) {
	        $result .= substr($hex, $x, 2);
	    }
	    return $result;
	}

  /**
   * Log a User into the website
   *
   * @param string $user Username
   * @param string $pass Password
   * @return true|false
   */
  public function login($user, $pass)
  {
    if(!isset($user)||!isset($pass)) {
        $this->err->logError(SH_E_WARN, 'login', 2003);
    }
    //before stuff happens let's reset all the user details
    //by running the logout function in graceful mode
    $this->logout('graceful');
    
    //First, make sure they submitted a username
    if(trim(strlen($user))==0) {
      $this->setError('Username was Empty', 'login');
      return false;
    }
    //Second, trim the username and password:
    $user = trim($user);
    $pass = trim($pass);
    
    //Third, connect to the LDAP server
    $ad=ldap_connect(SHOEHORN_LDAP_HOST);
    //return an error if it didn't work
    if($ad === false){
    	//boo, could not connect to the LDAP Server
		$this->setError('Could not connect to Authentication Server.', 'login');
		return false;
	}
	
	//Fourth: try to set the LDAP protocol version
	if(!ldap_set_option($ad, LDAP_OPT_PROTOCOL_VERSION, 3)) {
		//boo, could not set up server settings
		$this->setError('Could not setup the Authentication Server settings.', 'login');
		return false;
	}
	
	//Fifth: try to bind to the server using the provided username and password:
	$bd=@ldap_bind($ad, SHOEHORN_LDAP_USER_PREFIX . $user, $pass);
	if($bd === false) {
		//boo, couldn't connect, bad username or password!
		$this->setError('Invalid Username or Password.', 'login', true);
		return false;
	}    
	
    
    //If they got to this point, they must be a valid user.
    //So let's instantiate some variables!
    
    //new array to fill with link identifiers
    $ldapIdent = array(); //to hold link identifiers for each base DN
    $entriesArr = array(); //for search result entries for each base DN
    $foundSomething = false; //whether or not we found something yet
    
    //list of attributes to search for:
    $attributes = array('name', 'memberOf', 'displayName', 'mail', 'objectsid', 'objectguid');
    
    //filter based on username:
    $filter = '(sAMAccountname=' . $user . ')';
    
    //list of base DN's from the config file
    $dn = explode("|", SHOEHORN_LDAP_BASEDN);

    //ad a link identifier for each baseDN to use
    for($i=0;$i<count($dn); $i++) {
		$ldapIdent[]=$ad;
    }
    
    //now search the ldap on all the base DNs
    $result = @ldap_search($ldapIdent, $dn, $filter, $attributes);
    
    //let's make sure that worked (even if there are no results):
    if($result === false) {
		$this->setError("Error searching for your account.", "login", true);
		return false;
    }
    
    echo("<pre>\n");//for debugging

    //loop through all the base DN search results
    for($i=0;$i<count($dn);$i++) {
      //make sure we've got exactly one result from this base DN:
      if(ldap_count_entries($ad, $result[$i])==1) {
      	//grab the actual data from the results:
        $entriesArr[$i] = @ldap_get_entries($ad, $result[$i]);
        //false alarm, didn't work
        if($entriesArr[$i]===false) {
          unset($entriesArr[$i]);
        //woohoo, we must have found someone!
		} else {
			$foundSomething = true; //we found someone!
			//grab the string sid for the user
			$this->userDetails["sid"] = $this->bin_to_str_sid($entriesArr[$i][0]["objectsid"][0]);
			//grab the string guid for the user
			$this->userDetails["guid"] = $this->bin_to_str_guid($entriesArr[$i][0]["objectguid"][0]);
        }
      }
    }
    
    //if we didn't find something, log an error and return false
    if($foundSomething == false) {
      $this->setError("Could not gather user details.", "login", true);
      return false;  
    //otherwise, verify that we only got one entry
    } else {
      if(count($entriesArr)==1) {
      	//only one entry! let's create an array with just this entry!
        $entries = array_pop($entriesArr);
        //and clear out the old entries array
        unset($entriesArr);
      } else {
      	//oops, found too many, let's log an error and return false
        $this->setError("Found " . count($entriesArr) . " users " . "with this username.", "login", true);
        return false;
      }
    }
    
   
    //now we know there is only one person with that username... 
    //so let's find out more details about them
    
    //first, let's see if we can get group memebership IDs:
    if(isset($entries[0]["memberof"])) {
    	//grab the membership ID's if they exist
    	$this->getGroupIDs($ad, $entries[0]["memberof"]);
    } else {
    	//just set a blank array if they don't exist.
    	$this->groups = array();
    }
    
    
    //DEBUGGING STUFF:
    echo("\n<strong>GUID:</strong> " . $this->userDetails["guid"] . "\n<strong>SID:</strong> " 
        . $this->userDetails["sid"] . "\n");
    echo("<strong>Group Memberships:</strong>\n");
    if(count($this->groups)>0) {
	    foreach($this->groups as $g=>$gid) {
	    	echo('  <strong>' . $g . "</strong>\n    <em>ID:</em> " . $gid . "\n");
	    }
    } else {
    	echo("<em>no group memberships</em>\n");
    }
    
    echo("</pre>");//end debugging stuff
    
    //now we've got the user logged in, and have their group details
    //let's get the person's name now to personalize the page:
    if(isset($entries[0]["name"][0])) {
      $this->userDetails["name"] = trim($entries[0]["name"][0]);
    } else {
      $this->setError("Could not get the your name from the server.", "login");
      $this->logout();
      return false;
    }
    
    //okay, now let's get their email address:
    if(isset($entries[0]["mail"][0])) {
      $this->userDetails["email"] = trim($entries[0]["mail"][0]);
    } else {
      $this->setError("Could not get your email from the server.", "login");
      $this->logout();
      return false;
    }
    
    //now that we are authenticated, let's let the class know that:
    $this->authenticated = true;
    //and we know the username is good, so let's make a record of that:
    $this->userDetails["username"] = $user;
    
    //add the groups to the session:
    $_SESSION["groups"] = $this->groups;
    //add the user details to the session:
    $_SESSION["userDetails"] = $this->userDetails;
    //add a hash of the user's IP to make things slightly harder to spoof
    $_SESSION["id"] = md5($_SERVER['REMOTE_ADDR']);
    //just something to let us know the user is already authenticated
    $_SESSION["authenticated"] = true;
    
    //we're done here, let's return true!
    return true;
        
  }
	
    /**
     * Get group IDs based on memberof array from LDAP results
     *
     * @param link_identifier $ad
     * @param array $memberof
     * return true|false
     */
    private function getGroupIDs(&$ad, $memberof)
    {	
    	//first, make sure there are enough entries in the array
    	if(!is_array($memberof) || count($memberof)<1) {
    		$this->setError("Could not gather membership info.", "login");
    		$this->groups = array();
    		return false;
    	}
    	unset($memberof["count"]);  //remove the count variable
    	
    	//establish base variables
    	$groupsForSearch = array(); //groups we extract from the array
    	$groupLdapLink = array(); //copies of the $ad object to be searched
    	$tmpGroupIDs = array(); //placeholder for group IDs
    	$needle = array("\"", "(", ")", "*"); //bad LDAP strings
    	$repStr = array("\5C", "\28", "\29", "\2A");//LDAP escape sequences
    	
    	//walk through the array and pull the data for use later
    	foreach($memberof as $tmpdn) {
    		$tmpArr = explode(",", $tmpdn);
    		$groupsForSearch[] = str_replace($needle, $repStr,array_shift($tmpArr));
    	
    		//rebuild the DN minus the actual group (for a list of Base DNs)
    		$groupsDN[] = implode(",", $tmpArr);
    	}
    	
    	//filter base DN array so there are no duplicates
    	$groupsDN = array_unique($groupsDN);
    	
    	//build the search feature based on array we built earlier.
    	$groupSearchFilter = "(|(" . implode(")(", $groupsForSearch) . "))";
    	
    	//go through each base DN and make a copy of the AD link
    	for($i=0; $i<count($groupsDN); $i++) {
    		$groupLdapLink[] = $ad;
    	}
    	
    	//perform the actual LDAP search based on what we've found:
    	$groupSearch = ldap_search($groupLdapLink, $groupsDN, $groupSearchFilter, array("objectsid", "objectguid"));
    	
        //loop through each baseDN's search
        foreach($groupSearch as $currentResult) {
            //check to see if we found any results for this baseDN
            if(ldap_count_entries($ad, $currentResult)>0) {
                //get the search results for this baseDN
                $groupEntries = ldap_get_entries($ad, $currentResult);
                //walk down all of the entries we just extracted:
                foreach ($groupEntries as $tmpGrpArr) {
                    //convert the GUID and SID from binary if they exist:
                    if(isset($tmpGrpArr['objectguid']) && isset($tmpGrpArr['objectsid'])) {
                        //grab the human readable group name
                        $tmpGrpName = substr($tmpGrpArr["dn"], 3, 
                        (strpos($tmpGrpArr["dn"], ",")-3));
                        //grab the string version of the sid
                        $tmpSid = 
                        $this->bin_to_str_sid($tmpGrpArr['objectsid'][0]);
                        //grab the string version of the guid
                        $tmpGuid = 
                        $this->bin_to_str_guid($tmpGrpArr['objectguid'][0]);
                        $tmpGroupIDs[$tmpGrpName] = $tmpSid . "-" . $tmpGuid;
                    }
                }
            }
        }
    	$this->groups = array(); //reset the groups array
    	$this->groups = $tmpGroupIDs; //add the new data
    	return true;
    }
  
    /**
     * Determines whether or not the user is authenticated.
     * 
     * Simply returns the authenticated variable.  Using this method instead of
     * accessing the authenticated variable itself means we can make the 
     * authenticated variable private and thus only readable through this 
     * method... so someone couldn't just set $this->authenticated to true.
     *
     * @return true|false
     */
    public function isAuthenticated()
    {
     return $this->authenticated;
    }
  
    /**
     * Returns the groups a user is in.
     * 
     * Simply returns the groups variable.  This is so we can make it read-only,
     * just like the isAuthenticated() method makes the authenticated variable
     * read-only.
     *
     * @return array
     */
    public function getGroups()
    {
        return($this->groups);
    }
  
    /**
     * Log out of the website
     *
     * Simply unsets any authentication variables, destroys cookies and sessions
     * 
     * @param string $type Type of logout, normal or graceful
     */
    public function logout($type="normal")
    {
        //set the default variables
        $this->authenticated = false;
        $this->groups = array();
        $this->userDetails = array();
        if($type=="graceful") {
            unset($_SESSION["groups"], $_SESSION["userDetails"], $_SESSION["id"], $_SESSION["authenticated"]);
        } else {
            //remove any active sessions first by removing cookies
            if (isset($_COOKIE[session_name()])) {
                setcookie(session_name(), '', time()-42000, '/');
            }
            //then utterly destroy the session
            session_destroy();
            //then start a new session, yay!
            session_start();
        }
        //echo("logged out");
    }
    
    protected function setError($msg, $function, $public=false)
    {
        if($public===true) {
            $this->errors["public"][] = $msg;
        }
        $this->errors["dev"][]["message"] = $msg;
        $this->errors["dev"][]["function"] = $function;
    }
}